Lambda Streaming responseで、S3に置いた大きなJSONデータを編集しながらレスポンスしたらどれくらい速いのか調べてみた
4/7 のアップデートで Lambda が stream レスポンスを返せるようになりました。これにより、6MB よりも巨大なレスポンスを返すことができます。
前回調べたstream-jsonを用いることで、巨大な json を stream のまま編集することができます。stream のまま扱うことで展開する memory のサイズを抑えることができます。 lambda でこれを用いたときパフォーマンスにどのように影響するのかを調べるために、いくつかの実験をしてみました。
実験に使うデータ
実験に使うデータは以下の形式です。 1 分ごとの気温を記録した 1 年分のデータを模したもので、要素の数は 525,600 (365 * 24 * 60 ) 個です。
S3 に置く JSON ファイル
[ [1648771200000, 12.6], [1648771260000, 17.7], [1648771320000, 29.1], ... ]
一要素目はエポックミリ秒、二要素目は気温を模したランダムな数値です。
DynamoDB
上記のJSONデータと比較実験するために同じItem数のDynamoDBテーブルを用意しました。
deviceId(PK) | timestamp(SK) | value |
---|---|---|
"001" | 1648771200000 | 12.6 |
"001" | 1648771260000 | 17.7 |
"001" | 1648771320000 | 29.1 |
... | ... | ... |
timestamp はエポックミリ秒、value は気温を模したランダムな数値です。 deviceId には"001"の固定値を仮り置きしています。
実験の内容
3 種類の lambda 関数をそれぞれメモリサイズ 128MB, 256MB, 512MB で実行し、レスポンス完了までにかかる時間を調べました。
すべての関数とも、取得したデータを 1 ヶ月分だけに filter して返します。
1. s3 のデータを返す(stream)
import { Writable, Readable } from "node:stream"; import { Handler } from "aws-lambda"; import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; import { chain } from "stream-chain"; import { parser } from "stream-json"; import { streamArray } from "stream-json/streamers/StreamArray"; import { disassembler } from "stream-json/Disassembler"; import { stringer } from "stream-json/Stringer"; const client = new S3Client({}); declare var awslambda: { streamifyResponse: ( streamHandler: (event: JSON, responseStream: Writable) => Promise<void> ) => Handler; }; const from = new Date("2022-04-01T00:00Z").getTime(); const to = new Date("2022-05-01T00:00Z").getTime(); export const handler: Handler = awslambda.streamifyResponse( async (event: JSON, responseStream: Writable) => { // S3のファイルを読み出すstreamを作成 const output = await client.send( new GetObjectCommand({ Bucket: process.env.BUCKET_NAME, Key: "large-chart-data.json", }) ); // streamを流れるJSONデータを、編集しながらresponseStreamに流し込む chain([ output.Body as Readable, parser(), streamArray(), ({ value }) => { if (from <= value[0] && value[0] <= to) { return [value]; } return []; }, disassembler(), stringer({ makeArray: true }), ]).pipe(responseStream); } );
2. s3 のデータを返す(非 stream)
import { Handler } from "aws-lambda"; import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; const client = new S3Client({}); const from = new Date("2022-04-01T00:00Z").getTime(); const to = new Date("2022-05-01T00:00Z").getTime(); export const handler: Handler = async () => { const output = await client.send( new GetObjectCommand({ Bucket: process.env.BUCKET_NAME, Key: "large-chart-data.json", }) ); // S3ファイルをすべて配列に展開して`filter()`する const body = await output.Body?.transformToString(); const data = JSON.parse(body ?? ""); return data.filter( (item: [number, number]) => from <= item[0] && item[0] <= to ); };
3. DynamoDB のデータを返す(非 stream)
import { Handler } from "aws-lambda"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { DynamoDBDocument } from "@aws-sdk/lib-dynamodb"; const doc = DynamoDBDocument.from(new DynamoDBClient({})); type TableItem = { type: string; timestamp: number; value: number }; const from = new Date("2022-04-01T00:00Z").getTime(); const to = new Date("2022-05-01T00:00Z").getTime(); export const handler: Handler = async () => { const items: TableItem[] = await queryRecursively(); return items.map(({ timestamp, value }) => [timestamp, value]); }; async function queryRecursively(exclusiveStartKey?: any): Promise<any[]> { console.info({ exclusiveStartKey }); const { Items: items = [], LastEvaluatedKey: lastEvaluatedKey } = await doc.query({ TableName: process.env.TABLE_NAME, KeyConditionExpression: "deviceId = :deviceId AND #timestamp BETWEEN :from AND :to", ExpressionAttributeValues: { ":deviceId": "001", ":from": from, ":to": to, }, ExpressionAttributeNames: { "#timestamp": "timestamp", }, ExclusiveStartKey: exclusiveStartKey, }); return [ ...items, ...(lastEvaluatedKey ? await queryRecursively(lastEvaluatedKey) : []), ]; }
実験結果
コールドスタートを除いて 5 回実行した平均を記録しました。 (簡易のため平均値のみを確認しました。)
lambda | memory(MB) | time(ms) | used memory(MB) |
---|---|---|---|
s3 (stream) | 128 | 81,524 | 120 |
s3 (stream) | 256 | 40,241 | 144 |
s3 (stream) | 512 | 18,050 | 174 |
s3 (非 stream) | 128 | 33,847 | 128 |
s3 (非 stream) | 256 | 3,892 | 256 |
s3 (非 stream) | 512 | 1,733 | 319 |
DynamoDB | 128 | 6,779 | 128 |
DynamoDB | 256 | 3,402 | 163 |
DynamoDB | 512 | 2,139 | 191 |
考察
すべての応答を返すまでにかかる時間は、s3 の stream が最も遅かったです。
また、memory を 512 まで引き上げたケースでは、s3 の非 stream と DynamoDB はほぼ同じ結果となりました。
テーブルの item がより複雑なケースや、s3 に置いた json からどのような加工をするかで、これらの結果は変化する可能性が大きいです。 そのためそれぞれのユースケースで検証が必要であると言えます。
まとめ
今回の調査で、Lambda の stream レスポンスを使用する場合は、パフォーマンス(特にすべてのレスポンスが完了するまでにかかる時間)は遅くなる可能性が高いことがわかりました。 公式アナウンスや公式ブログにある通り、「6MB を超えるレスポンスが必要」や「レスポンスを順次表示することが重要」などのユースケースで有効な機能であると思います。
「レスポンスを順次表示することが重要」なユースケースではクライアントサイドでも stream-json によるパースは有効であると言えます。 また、応答に csv 形式を用いる場合は、同じ作者の stream-csv-as-json が有効になる場合があるかもしれません。
検証に使ったコードはすべてここに置いてあります。